feat(core, react): add step up mfa error handler#88
Conversation
🚀 Preview deploymentBranch: 📝 Preview URL: https://auth0-universal-components-ptysuvg7w-okta.vercel.app Updated at 2026-03-10T13:03:02.482Z |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #88 +/- ##
==========================================
- Coverage 88.20% 84.29% -3.91%
==========================================
Files 145 151 +6
Lines 12533 13331 +798
Branches 1603 1375 -228
==========================================
+ Hits 11055 11238 +183
- Misses 1478 2093 +615 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…-ui-components into feat/mfa-list-challenge-verify-flow-UIC-573
…/github.com/auth0/auth0-ui-components into feat/mfa-list-challenge-verify-flow-UIC-573
| if (isFetchLoading) { | ||
| return ( | ||
| <div | ||
| style={currentStyles.variables} | ||
| className="flex items-center justify-center min-h-96 w-full" | ||
| > | ||
| <Spinner /> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
looks like this is missing or intentionally removed?
There was a problem hiding this comment.
handled by gatekeeper now
| if (isLoading || isLoadingConfig || isLoadingIdpConfig) { | ||
| return ( | ||
| <div className="flex justify-center items-center p-8"> | ||
| <Spinner /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
| const LoadingState = () => ( | ||
| <div className="flex items-center justify-center p-8"> | ||
| <Spinner /> | ||
| </div> | ||
| ); | ||
|
|
||
| const ErrorState = () => ( | ||
| <div className="text-center text-destructive py-4">{t('error.mfa.fetch_failed')}</div> | ||
| ); | ||
|
|
||
| const EmptyState = () => ( | ||
| <div className="text-center text-muted-foreground py-4">{t('error.mfa.no_authenticators')}</div> | ||
| ); | ||
|
|
||
| const AuthenticatorList = ({ items }: { items: StepUpAuthenticator[] }) => ( | ||
| <div className="space-y-2 py-4"> | ||
| {items.map((auth) => ( | ||
| <div key={auth.id} className="border rounded p-3"> | ||
| <div className="font-medium">{auth.name || auth.authenticatorType}</div> | ||
| <div className="text-sm text-muted-foreground"> | ||
| Type: {auth.authenticatorType} | Active: {auth.active ? 'Yes' : 'No'} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
|
|
||
| const EnrollmentList = ({ factors }: { factors: EnrollmentFactor[] }) => ( | ||
| <div className="space-y-2 py-4"> | ||
| <div className="text-sm text-muted-foreground text-center mb-4"> | ||
| {t('error.mfa.enrollment_required')} | ||
| </div> | ||
| {factors.map((factor) => ( | ||
| <div key={factor.type} className="border rounded p-3"> | ||
| <div className="font-medium">{factor.type}</div> | ||
| <div className="text-sm text-muted-foreground">{t('error.mfa.factor_available')}</div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
can we move them outside of gatekeeper and use it?
There was a problem hiding this comment.
yes , was in a pr opened by harish , its merged into this branch now
| setIsRetrying(true); | ||
| try { | ||
| await onRetry(); | ||
| setIsMfaDialogOpen(true); |
There was a problem hiding this comment.
not sure if we need to reopen mfa dialog on every retry? should there be any conditional logic here?
There was a problem hiding this comment.
refactored it
| const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; | ||
|
|
||
| const isActionCancelledError = (error: unknown): boolean => { | ||
| return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; | ||
| }; | ||
|
|
||
| export interface UseSsoProvisioningOptions { | ||
| provisioning?: SsoProvisioningTabEditProps; | ||
| customMessages?: Record<string, unknown>; | ||
| } |
There was a problem hiding this comment.
can be reusable from utils I saw the same in scim token hook
There was a problem hiding this comment.
yup moved it into lib/utils
| const domain = auth.domain ?? auth.contextInterface.getConfiguration()?.domain; | ||
| if (!domain) throw new Error('SpaTokenRetriever: Auth0 domain is not configured'); | ||
|
|
||
| const audience = buildAudience(domain, audiencePath); |
There was a problem hiding this comment.
we should define what will happen with empty audience path, need to add sanity check here
There was a problem hiding this comment.
adding a guard here isn't necessary since:
This is an internal function — no external consumers call getToken directly
All callers hardcode the audience path ('me', 'my-org')
| return { | ||
| /** | ||
| * Retrieves an access token for the specified scope and audience. | ||
| * @param scope - The OAuth scope to request. | ||
| * @param audiencePath - The API audience path segment. | ||
| * @param ignoreCache - Whether to bypass the token cache. | ||
| * @returns The access token, or undefined if using proxy mode. | ||
| */ | ||
| async getToken( |
There was a problem hiding this comment.
will there be any scope of adding further method under this? if not we should consider returning the anonymous function instead
There was a problem hiding this comment.
as of now , no , but we might need to extend it in future ! will leave it as is
| * @returns Proxy-based MFA client. | ||
| */ | ||
| function createProxyMfaClient(authProxyUrl: string): Omit<MfaApiClient, 'getEnrollmentFactors'> { | ||
| const baseUrl = authProxyUrl.replace(/\/$/, ''); |
There was a problem hiding this comment.
before we do any operations here we should wrap authProxyUrl with new URL(authProxyUrl) for sanity
There was a problem hiding this comment.
authProxyUrl is a relative path (e.g., / or /api/auth), so new URL() would throw since it requires an absolute URL — the simple trailing-slash strip is sufficient here and the value is developer-configured, not user input.
|
|
||
| return { | ||
| getAuthenticators: async (mfaToken: string) => { | ||
| const response = await fetch(`${baseUrl}/auth/mfa/authenticators?mfa_token=${mfaToken}`); |
There was a problem hiding this comment.
can we wrap mfaToken with encodeURIComponent?
There was a problem hiding this comment.
agreed !, updated
| const response = await fetch(`${baseUrl}/auth/mfa/enroll`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| return handleResponse<EnrollmentResponse>(response); | ||
| }, | ||
|
|
||
| challenge: async (params: ChallengeAuthenticatorParams) => { | ||
| const response = await fetch(`${baseUrl}/auth/mfa/challenge`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| mfaToken: params.mfaToken, | ||
| challengeType: params.challengeType, | ||
| authenticatorId: params.authenticatorId, | ||
| }), | ||
| }); | ||
| return handleResponse<ChallengeResponse>(response); | ||
| }, | ||
|
|
||
| verify: async (params: VerifyParams) => { | ||
| const response = await fetch(`${baseUrl}/auth/mfa/verify`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(params), | ||
| }); | ||
| return handleResponse<TokenEndpointResponse>(response); |
There was a problem hiding this comment.
bit of common pattern here for every mfa action, can we consider reusing with a common request function?
There was a problem hiding this comment.
updated , created an new utils in api folder
| audiencePath: string, | ||
| ignoreCache = false, | ||
| ): Promise<string | undefined> { | ||
| if (auth.authProxyUrl) return undefined; |
There was a problem hiding this comment.
I think this check can be placed on the callee function to avoid invocation of getToken itself in the first place
There was a problem hiding this comment.
agreed , since this is never used in proxy mode , will move the check to the callee
…w-UIC-573 feat(react): MFA step-up challenge & enrolment flow
…w-UIC-573 fix(core,react): review comments update
| const auth: AuthDetails = { | ||
| authProxyUrl: 'https://proxy.example.com/', | ||
| }; |
There was a problem hiding this comment.
this auth object is created repeatedly in multiple it statements, we should reuse if there is no reference changes
There was a problem hiding this comment.
agreed , updated
| className="text-sm" | ||
| variant="outline" | ||
| size="default" | ||
| variant="ghost" |
There was a problem hiding this comment.
this change will impact existing mfa as well
| <CardTitle className="text-base font-semibold">{displayName}</CardTitle> | ||
| {formattedDate && ( | ||
| <CardDescription className="text-xs"> | ||
| {t('error.mfa.registered_on').replace('${date}', formattedDate)} |
There was a problem hiding this comment.
not sure why do we need to use replace here
t('error.mfa.registered_on', { date: formattedDate })
this way should work
| name="userOtp" | ||
| render={({ field }) => ( | ||
| <FormItem> | ||
| <FormLabel className="text-sm font-medium" htmlFor="step-up-otp-input"> |
There was a problem hiding this comment.
there is a potential accessibility bug here the textfield depends on isRecoveryCode but our form label will always look for id with step-up-otp-input
| variant="primary" | ||
| size="sm" | ||
| disabled={ | ||
| !userOtp?.trim() || (!isRecoveryCode && userOtp.length !== 6) || isVerifying |
There was a problem hiding this comment.
would be good in terms of readability if we can have
const isSubmitDisabled =
isVerifying || !userOtp?.trim() || (!isRecoveryCode && userOtp.length !== 6);
then
disabled={isSubmitDisabled}
| const typeToTranslationKey: Record<string, string> = { | ||
| 'push-notification': 'push', | ||
| phone: 'sms', | ||
| totp: 'otp', | ||
| }; |
There was a problem hiding this comment.
looks like this const is repeated saw in step-up-authenticator-list as well
| const map: Record<string, MFAType> = { | ||
| otp: FACTOR_TYPE_TOTP, | ||
| totp: FACTOR_TYPE_TOTP, | ||
| 'push-notification': FACTOR_TYPE_PUSH_NOTIFICATION, | ||
| sms: FACTOR_TYPE_PHONE, | ||
| phone: FACTOR_TYPE_PHONE, | ||
| email: FACTOR_TYPE_EMAIL, | ||
| 'recovery-code': FACTOR_TYPE_RECOVERY_CODE, | ||
| }; |
There was a problem hiding this comment.
this constant map is getting recreated inside a loop we should segregate it from this function
| const renderInstallationPhase = () => ( | ||
| <div className="w-full max-w-sm mx-auto"> | ||
| <div className="flex flex-col items-center justify-center flex-1 space-y-10"> | ||
| <p className={cn('text-center text-primary text-sm font-normal')}> |
There was a problem hiding this comment.
we don't need cn for static classes
| if (!otpData?.barcodeUri) { | ||
| fetchOtpEnrollment(); | ||
| } | ||
| }, [otpData?.barcodeUri]); |
There was a problem hiding this comment.
we are missing fetchOtpEnrollment dep here
| <div style={currentStyles.variables} className="w-full max-w-sm mx-auto text-center"> | ||
| <div className="space-y-6"> | ||
| <div> | ||
| <p className={cn('font-normal block text-sm text-center mb-4 text-primary')}> |
| expires_in: 3600, | ||
| }), | ||
| }); | ||
|
|
There was a problem hiding this comment.
I think we should separate everything related to MFA into different files (types, mocks, etc).
This is a new API that should not be related to coreClient
| getEnrollmentFactors(mfaToken: string): Promise<EnrollmentFactor[]>; | ||
| verify(params: VerifyParams): Promise<TokenEndpointResponse>; | ||
| } | ||
|
|
There was a problem hiding this comment.
Please, separate all types for MFA in a different file
| }, | ||
| stepUpApiService: undefined, | ||
| getStepUpApiService: function () { | ||
| return undefined as unknown as ReturnType<CoreClientInterface['getStepUpApiService']>; |
There was a problem hiding this comment.
We should never use casts unless there is no other option.
Here this function can just return ReturnType<CoreClientInterface['getStepUpApiService']> | undefined or probably follow the same approach you have in getMyAccountApiClient, using throw new Error('Function not implemented.');
| return undefined; | ||
| }, | ||
| stepUpApiService: undefined, | ||
| getStepUpApiService: function () { |
There was a problem hiding this comment.
Why getStepUpApiService instead of getStepUpApiClient` to follow the naming convention we already have in this file?
| @@ -1,7 +1,46 @@ | |||
| { | |||
| "common": { | |||
There was a problem hiding this comment.
most of these messages are not "common", they are part of a new component!
| return client; | ||
| }; | ||
|
|
||
| return client; |
There was a problem hiding this comment.
Please, check ALL services and do not cast them
| */ | ||
| export function initializeStepUpApiService(auth: AuthDetails): StepUpApiService { | ||
| if (auth.authProxyUrl) { | ||
| return createProxyMfaClient(auth.authProxyUrl) as StepUpApiService; |
There was a problem hiding this comment.
Return correct types instead casting this
| @@ -893,7 +893,7 @@ interface ComponentAction<T, U = undefined> { | |||
| <li> | |||
There was a problem hiding this comment.
| @@ -893,7 +893,7 @@ interface ComponentAction<T, U = undefined> { | |||
| <li> | |||
| <code>tabs.provisioning.content.notifications.*</code> – Notification messages | |||
| @@ -893,7 +893,7 @@ interface ComponentAction<T, U = undefined> { | |||
| <li> | |||
| <code>tabs.provisioning.content.notifications.*</code> – Notification messages | |||
| (delete_success, remove_success, update_success, general_error, | |||






Changes
This PR introduces MFA step-up authentication support across both SPA and proxy modes, replaces the withServices + ScopeManagerProvider pattern with a centralized GateKeeper component, and refactors all block components into a container → guard → view architecture.
Motivation
Block components relied on withServices + ScopeManagerProvider for authorization, which:
No built-in MFA step-up handling
There was no transparent handling for HTTP 403 mfa_required errors.
Hook indirection added unnecessary complexity
The hooks were split into paired files (e.g., use-domain-table and use-domain-table-logic), but the separation of concerns between them is not very clear.
Core Package (@auth0/universal-components-core)
Added
Step-Up API Service
Step-Up Utilities
SPA Token Retriever
Removed
Updated
React Package (@auth0/universal-components-react)
New: GateKeeper
Central guard component that intercepts loading, error, and MFA states before rendering children.
Behavior:
Architecture Refactor (All 6 Block Components)
New pattern:
Container → GateKeeper → View
Container
GateKeeper
View
Refactored components:
Removed
Logic consolidated into primary hooks.
Updated
useErrorHandler
SPA/Proxy providers
Types
Key Improvements
References
Testing
Checklist